Skip to content

Masquerade bar#255

Open
jesusbalderramawgu wants to merge 5 commits intoopenedx:mainfrom
WGU-Open-edX:masqueradeBar
Open

Masquerade bar#255
jesusbalderramawgu wants to merge 5 commits intoopenedx:mainfrom
WGU-Open-edX:masqueradeBar

Conversation

@jesusbalderramawgu
Copy link
Copy Markdown
Contributor

Masquerade bar

As part of this issue on the instructor dashboard the masquerade bar needed to be added to frontend base.

description

The masquerade Bar was took it from learning project, and updated to use react query and context inside the frontend base.

screenshots

this is an example running on instructor dashboard
Example of role Staff
Screenshot 2026-04-29 at 3 20 37 p m
Example of role Student
Screenshot 2026-04-29 at 3 21 31 p m

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Apr 29, 2026
@jesusbalderramawgu jesusbalderramawgu marked this pull request as draft April 29, 2026 21:22
@openedx-webhooks
Copy link
Copy Markdown

openedx-webhooks commented Apr 29, 2026

Thanks for the pull request, @jesusbalderramawgu!

This repository is currently maintained by @openedx/axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

Comment thread shell/header/masquerade-bar/utils.ts Outdated

/*
* Collects route role strings from all apps that opted into the course
* navigation bar feature. Each app declares its roles as a string array:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to change this for masquerade bar feature 😅

Comment thread shell/header/masquerade-bar/messages.ts Outdated
defaultMessage: 'Studio',
description: 'Button to view in studio',
},
titleInsights: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one i think is not longer used

@@ -0,0 +1,142 @@
import React from 'react';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this line is not needed

Copy link
Copy Markdown
Contributor Author

@jesusbalderramawgu jesusbalderramawgu Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is in use

@jesusbalderramawgu jesusbalderramawgu marked this pull request as ready for review April 30, 2026 13:53
import { defineMessages } from '@openedx/frontend-base';

const messages = defineMessages({
genericError: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think you can merge this messages, with the other file so we just have one absolute file for masquerade bar messages

Copy link
Copy Markdown
Contributor

@arbrandes arbrandes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made some bug-level comments inline, but the meat of the changes I'd like to see com from the patterns suggested below (with Claude's help, of course). They're informed by what the learner-dashboard masquerade bar does well and would also unlock the "kill the page reload" goal. They're listed in roughly the order I'd take them on.

Replace global.location.reload() with query invalidation

shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx:36-38, MasqueradeUserNameInput.tsx:21-22

The reload is the only thing keeping this bar's state model from matching the dashboard bar's. After the LMS accepts the masquerade POST, the cookie is updated server-side; everything else just needs to refetch. With the shell's QueryClientProvider in scope, that becomes:

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: (payload: Payload) => postMasqueradeOptions(courseId, payload),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['masquerade', courseId] });
    queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'course' }); // or similar
  },
});

Consumers (course outline, unit content, etc.) refetch under the new identity without losing scroll position, route, or any other in-page state. This is the single biggest engineering win and unblocks most of what follows.

Pull state and effects into a useMasqueradeWidget hook

shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx:31-103

Today the component body holds useQuery, useMutation, three useStates (autoFocus, shouldShowUserNameInput, activeOverride), three useEffects, two useCallbacks, and a useMemo that merges activeOverride onto queryActive. The Learner Dashboard bar's useMasqueradeBarData shows the alternative — a custom hook that owns all of that and returns flat data + handlers, leaving the component as JSX.

// hooks.ts
export function useMasqueradeWidget(courseId: string) {
  const query = useQuery({ queryKey: ['masquerade', courseId], queryFn: () => getMasqueradeOptions(courseId) });
  const mutation = useMutation({ /* ... */ });

  const active = query.data?.success ? query.data.active : defaultActive;
  const available = query.data?.success ? query.data.available : [];

  const isError = query.isError || (query.data && !query.data.success) || mutation.isError;
  const errorMessage = pickErrorMessage(query, mutation); // returns a MessageDescriptor

  return { active, available, isError, errorMessage, isPending: mutation.isPending, submit: mutation.mutateAsync };
}

Once this hook owns the state, MasqueradeWidget shrinks to ~30 lines of JSX wired to its return value, and MasqueradeBar no longer needs to lift masqueradeErrorMessage via callback (see below).

Derive state instead of mirroring it via useState + useEffect

shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx:65-70, shell/header/masquerade-bar/MasqueradeBar.tsx:40

Several pieces of state are just shadows of query/mutation status:

  • shouldShowUserNameInput is "did the server return a userName, or did the user click Specific Student" — both knowable without a second useState + useEffect.
  • activeOverride exists to optimistically reflect the dropdown selection until the page reloads. Once the reload is gone and invalidateQueries refetches, query.data.active becomes the source of truth and the override disappears.
  • masqueradeErrorMessage in MasqueradeBar is just the mutation/query's error in a different bag, transferred via an onError callback. With the hook above, the bar can read it directly from useMasqueradeWidget (or pass the widget into the bar) instead of routing it through state.

The rule of thumb: if a useEffect exists only to copy a value from a hook into a useState, the useState shouldn't exist. The same applies to setActiveOverride after a successful submit — let the query be the truth.

Use Paragon's StatefulButton and FormControlFeedback for submit UX

shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx:41-48, shell/header/masquerade-bar/MasqueradeBar.tsx:69-75

Today there is no pending state on the username submit and errors only render as a top-level Alert outside the form. The dashboard bar uses two Paragon primitives that fit better:

<FormGroup isInvalid={isMasqueradingFailed}>
  <FormControl
    value={input}
    onChange={(e) => setInput(e.target.value)}
    floatingLabel={formatMessage(messages.placeholder)}
  />
  {isMasqueradingFailed && (
    <FormControlFeedback type="invalid" hasIcon={false}>
      {formatMessage(errorMessage)}
    </FormControlFeedback>
  )}
</FormGroup>
<StatefulButton
  variant="brand"
  state={isPending ? 'pending' : 'default'}
  labels={{ default: formatMessage(messages.submit), pending: formatMessage(messages.submitting) }}
  onClick={handleSubmit}
/>

That gives users a spinner on the submit button, inline feedback under the field, and lets the top-level Alert go away (or be reserved for the GET-options failure case, which is the only error not tied to a specific input).

i18n'd error messages keyed by failure type

shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx:42-53

Right now every error path produces the same English string, "Unable to get masquerade options". The dashboard bar shows a better pattern — derive the message from the error type and return a MessageDescriptor, not a pre-formatted string:

function pickErrorMessage(query, mutation) {
  if (query.isError) return messages.failedToLoadOptions;
  if (query.data && !query.data.success) return messages.serverRefusedOptions;
  if (mutation.isError) {
    const status = mutation.error?.customAttributes?.httpErrorStatus;
    if (status === 404) return messages.noStudentFound;
    return messages.genericSubmitError;
  }
  return null;
}

The component then calls formatMessage(errorMessage) at the render site, keeping MessageDescriptors in the data layer and translation in the view layer. This also lets success: false from the server carry data.error straight through (postMasqueradeOptions already returns error?: string) for cases where the server's message is more specific than anything the frontend could pick.

Lift the username input value into the hook

shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx:34-48

The input is uncontrolled today: it seeds itself with defaultValue={active.userName ?? ''} and the submit handler reads event.currentTarget.value out of the keypress event. That means the value lives only in the DOM, the submit path is forced to be a key-event handler (which is why onKeyPress got reached for in the first place), and there's no way for the hook to validate, disable a submit button, or clear the field after success.

Once the hook owns the data, lift the value alongside it:

// in useMasqueradeWidget
const [userName, setUserName] = useState('');

// reset to whatever the server says is active when query data arrives
useEffect(() => { setUserName(active.userName ?? ''); }, [active.userName]);

const handleSubmit = () => mutation.mutateAsync({ role: 'student', user_name: userName });

return { /* ... */ userName, setUserName, handleSubmit };

Then MasqueradeUserNameInput becomes a controlled FormControl driven by value / onChange, and submit fires off a button click (the StatefulButton from the previous section) rather than off Enter inside a key handler. Keyboard submit is still supported by wrapping the input + button in <form onSubmit={...}> — the difference is that userName is the source of truth and the event is just a trigger.

Collapse MasqueradeWidgetOption.handleClick to a single select(option) callback

shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx:23-40

Today each option's click handler decides for itself which path to take: if (userName || userName === '') opens the username input, otherwise it builds a Payload and calls onSubmit. The "is userName defined including the empty-string case" check is load-bearing (an empty string distinguishes "Specific Student..." from a fixed group like "Audit"), but it's exactly the kind of branch that belongs in the hook with the rest of the masquerade state, not duplicated per option.

Once the hook owns the decision, this component just forwards the option:

// in useMasqueradeWidget
const select = (option: MasqueradeOption) => {
  if (option.userName !== undefined) {
    setShowUserNameInput(true);
    setPendingOption(option);
    return;
  }
  return mutation.mutateAsync(toPayload(option));
};

// MasqueradeWidgetOption
const { select } = useMasqueradeContext();
return <Dropdown.Item onClick={() => select(option)}>{option.name}</Dropdown.Item>;

That removes the if (userName || userName === '') check, the per-option Payload assembly, the role!/userPartitionId! non-null assertions, and the option-side global.location.reload() call. MasqueradeWidgetOption becomes a thin presentational wrapper, and the rules for what each option means live in one place.

Extract the Studio link as its own component

shell/header/masquerade-bar/MasqueradeBar.tsx:11-22, 56-66

getStudioUrl and the "View course in: Studio" JSX have nothing to do with masquerading — they're just an instructor-context affordance that happens to share the bar. Pulling them into a <StudioLink courseId unitId /> (or a slot of its own) lets the URL-building logic be tested in isolation, lets isStudioButtonVisible go away in favor of "render or don't", and shrinks MasqueradeBar down to the masquerade flow.

Comment thread shell/index.ts Outdated
export { default as shellApp } from './app';
export { Footer, footerApp } from './footer';
export { providesCourseNavigationRolesId, Header, headerApp, HelpButton, helpButtonSlotOperation, helpWidgetId } from './header';
export { providesCourseNavigationRolesId, Header, headerApp, HelpButton, helpButtonSlotOperation, helpWidgetId, providesMasqueradeBarRolesId } from './header';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's clean this up. Sort and make it multi-line.

const { formatMessage } = useIntl();

return (!didMount ? null : (
<QueryClientProvider client={queryClient}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not necessary.


return (!didMount ? null : (
<QueryClientProvider client={queryClient}>
<div data-testid="instructor-toolbar">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try to avoid using data-testid as much as possible and use more human-mimicking testing patterns.

Also, this bar is supposed to be generic, right? Not just for the Instructor Dashboard.

import { appId } from '../constants';

function getStudioUrl(courseId?: string, unitId?: string): string | undefined {
const urlBase = getAppConfig(appId).STUDIO_BASE_URL;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have a siteConfig.studioBaseUrl, yet? If not it should be added as part of this.

<Form.Control
aria-labelledby="masquerade-search-label"
label={intl.formatMessage(messages.userNameLabel)}
onKeyPress={handleKeyPress}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude tells me that onKeyPress has been deprecated. We should be using onKeyDown instead.

await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled());
// Verify the widget rendered (the error propagation from MasqueradeWidget
// to MasqueradeBar's Alert is an integration concern tested separately)
expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned elsewhere, we have to avoid using getByTestId as much as possible.

}, []);

const urlStudio = getStudioUrl(courseId, unitId);
const [masqueradeErrorMessage, showMasqueradeError] = useState<string | null>(null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const [masqueradeErrorMessage, showMasqueradeError] = useState<string | null>(null);
const [masqueradeErrorMessage, setMasqueradeErrorMessage] = useState<string | null>(null);

const handleSubmit = React.useCallback(async (payload: Payload) => {
onError(''); // Clear any error
return mutation.mutateAsync(payload);
}, [onError, mutation]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useMutation returns a fresh object on every render, so depending on mutation in useCallback defeats the memoization. Depending on mutation.mutateAsync is the usual fix.

};
onSubmit(payload).then((data) => {
if (data && data.success) {
global.location.reload();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really have to reload the entire page? There must be a more elegant solution.

payload.user_partition_id = userPartitionId!;
}
onSubmit(payload).then(() => {
global.location.reload();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted elsewhere, there must be a better option that reloading the whole page.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Needs Triage

Development

Successfully merging this pull request may close these issues.

4 participants